@moxn/kb-migrate 0.4.34 → 0.4.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-error.d.ts +14 -0
- package/dist/api-error.js +19 -0
- package/dist/api-error.test.d.ts +1 -0
- package/dist/api-error.test.js +26 -0
- package/dist/client.d.ts +61 -0
- package/dist/client.js +96 -0
- package/dist/export.d.ts +11 -3
- package/dist/export.js +96 -159
- package/dist/export.test.d.ts +1 -0
- package/dist/export.test.js +107 -0
- package/dist/grammar-media.d.ts +52 -0
- package/dist/grammar-media.js +77 -0
- package/dist/grammar-media.test.d.ts +1 -0
- package/dist/grammar-media.test.js +108 -0
- package/dist/import-local.d.ts +41 -0
- package/dist/import-local.js +206 -0
- package/dist/import-local.test.d.ts +1 -0
- package/dist/import-local.test.js +19 -0
- package/dist/index.js +8 -9
- package/dist/sources/local.d.ts +24 -0
- package/dist/sources/local.js +22 -0
- package/dist/sources/local.test.js +1 -1
- package/dist/sources/notion.d.ts +7 -1
- package/dist/sources/notion.js +14 -8
- package/dist/sources/notion.test.d.ts +1 -0
- package/dist/sources/notion.test.js +45 -0
- package/package.json +9 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a failed `/api/v1/kb/*` response body into a single error message.
|
|
3
|
+
*
|
|
4
|
+
* The app's error envelope is `{ error?: string, code?: string, details?: unknown }`.
|
|
5
|
+
* `details` is frequently a structured OBJECT (e.g. a 409's conflicting path),
|
|
6
|
+
* so the old `\`...: ${body.details}\`` interpolation rendered the useless
|
|
7
|
+
* "[object Object]". Prefer the human `error` string; fall back to `details`
|
|
8
|
+
* (JSON-stringified when it's an object); finally just the status.
|
|
9
|
+
*/
|
|
10
|
+
export declare function formatApiError(status: number, body: {
|
|
11
|
+
error?: unknown;
|
|
12
|
+
details?: unknown;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
} | null | undefined): string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a failed `/api/v1/kb/*` response body into a single error message.
|
|
3
|
+
*
|
|
4
|
+
* The app's error envelope is `{ error?: string, code?: string, details?: unknown }`.
|
|
5
|
+
* `details` is frequently a structured OBJECT (e.g. a 409's conflicting path),
|
|
6
|
+
* so the old `\`...: ${body.details}\`` interpolation rendered the useless
|
|
7
|
+
* "[object Object]". Prefer the human `error` string; fall back to `details`
|
|
8
|
+
* (JSON-stringified when it's an object); finally just the status.
|
|
9
|
+
*/
|
|
10
|
+
export function formatApiError(status, body) {
|
|
11
|
+
const detail = typeof body?.error === 'string' && body.error.length > 0
|
|
12
|
+
? body.error
|
|
13
|
+
: body?.details != null
|
|
14
|
+
? typeof body.details === 'string'
|
|
15
|
+
? body.details
|
|
16
|
+
: JSON.stringify(body.details)
|
|
17
|
+
: '';
|
|
18
|
+
return detail ? `API error ${status}: ${detail}` : `API error: ${status}`;
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { formatApiError } from './api-error.js';
|
|
3
|
+
describe('formatApiError', () => {
|
|
4
|
+
it('renders a structured `details` object as JSON, not "[object Object]"', () => {
|
|
5
|
+
// The 409 the import path used to surface: { error, code, details: {...} }.
|
|
6
|
+
const msg = formatApiError(409, {
|
|
7
|
+
error: 'Path already exists',
|
|
8
|
+
code: 'CONFLICT',
|
|
9
|
+
details: { path: '/imports/runbook', conflictingId: 'abc' },
|
|
10
|
+
});
|
|
11
|
+
// Prefer the human message; never leak "[object Object]".
|
|
12
|
+
expect(msg).not.toContain('[object Object]');
|
|
13
|
+
expect(msg).toContain('409');
|
|
14
|
+
expect(msg).toContain('Path already exists');
|
|
15
|
+
});
|
|
16
|
+
it('falls back to a JSON-stringified details when there is no error string', () => {
|
|
17
|
+
const msg = formatApiError(400, { details: { reason: 'bad input' } });
|
|
18
|
+
expect(msg).toBe('API error 400: {"reason":"bad input"}');
|
|
19
|
+
});
|
|
20
|
+
it('uses a plain string detail as-is', () => {
|
|
21
|
+
expect(formatApiError(500, { details: 'boom' })).toBe('API error 500: boom');
|
|
22
|
+
});
|
|
23
|
+
it('degrades to a bare status when the body carries no error/details', () => {
|
|
24
|
+
expect(formatApiError(503, {})).toBe('API error: 503');
|
|
25
|
+
});
|
|
26
|
+
});
|
package/dist/client.d.ts
CHANGED
|
@@ -3,6 +3,31 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { ExtractedDocument, MigrationOptions, MigrationResult, DocumentListItem, DocumentDetail, ExportOptions } from './types.js';
|
|
5
5
|
import type { DateFilter } from './date-filter.js';
|
|
6
|
+
/**
|
|
7
|
+
* Grammar-markdown export of a document (`get_document_markdown` action on
|
|
8
|
+
* /api/v1/kb/export). `markdown` is the moxn-grammar interchange: front-matter
|
|
9
|
+
* + body + `:::image/:::csv/:::file/:::db` embeds with STORAGE refs.
|
|
10
|
+
*/
|
|
11
|
+
export interface DocumentMarkdownResponse {
|
|
12
|
+
id: string;
|
|
13
|
+
branchName: string;
|
|
14
|
+
markdown: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Result of an `import_markdown` UPSERT (action on /api/v1/kb/import).
|
|
18
|
+
* Mirrors the server's KBDocumentImportMarkdownResult.
|
|
19
|
+
*/
|
|
20
|
+
export interface ImportMarkdownResult {
|
|
21
|
+
id: string;
|
|
22
|
+
/** The CANONICAL path the doc landed at (`<parent>/<slug(name)>`), not the requested path. */
|
|
23
|
+
path: string;
|
|
24
|
+
outcome: 'created' | 'updated' | 'skipped';
|
|
25
|
+
existed: boolean;
|
|
26
|
+
/** The resolved document name (front-matter name, first H1, or filename stem). */
|
|
27
|
+
name?: string;
|
|
28
|
+
/** True when a front-matter block was present but NOT applied (no name → body-only import). */
|
|
29
|
+
frontMatterIgnored?: boolean;
|
|
30
|
+
}
|
|
6
31
|
export declare class MoxnClient {
|
|
7
32
|
private apiUrl;
|
|
8
33
|
private apiKey;
|
|
@@ -22,6 +47,42 @@ export declare class MoxnClient {
|
|
|
22
47
|
* Get full document detail with sections and content.
|
|
23
48
|
*/
|
|
24
49
|
getDocument(documentId: string): Promise<DocumentDetail>;
|
|
50
|
+
/**
|
|
51
|
+
* Get a document's GRAMMAR markdown (front-matter + body + `:::image/:::csv/
|
|
52
|
+
* :::file/:::db` embeds with STORAGE refs) via the `get_document_markdown`
|
|
53
|
+
* export action. This is the grammar interchange the local file-adapter
|
|
54
|
+
* writes to disk (Phase 9a-3) — the export IS the MCP `read`.
|
|
55
|
+
*
|
|
56
|
+
* `forEdit: true` keeps the H2 `{#id}` section anchors so a re-import can
|
|
57
|
+
* address sections stably.
|
|
58
|
+
*/
|
|
59
|
+
getDocumentMarkdown(documentId: string, opts?: {
|
|
60
|
+
forEdit?: boolean;
|
|
61
|
+
}): Promise<DocumentMarkdownResponse | null>;
|
|
62
|
+
/**
|
|
63
|
+
* Build a `storageKey → signedDownloadUrl` map for a document's media, by
|
|
64
|
+
* reusing the `get_document_content` export action (the blocks read, which
|
|
65
|
+
* resolves each storage ref to a fresh signed URL). The grammar markdown
|
|
66
|
+
* carries storage KEYS in its embed `ref`s — this is how the file-adapter
|
|
67
|
+
* turns a key into a downloadable URL WITHOUT a new server endpoint.
|
|
68
|
+
* Returns an empty map when the document has no resolvable media.
|
|
69
|
+
*/
|
|
70
|
+
getDocumentMediaUrls(documentId: string): Promise<Map<string, string>>;
|
|
71
|
+
/**
|
|
72
|
+
* UPSERT a document from GRAMMAR markdown via the `import_markdown` action.
|
|
73
|
+
* The inverse of {@link getDocumentMarkdown}: front-matter → name/path/tags/
|
|
74
|
+
* refs/properties, body → sections, all via the shipped `replace_document`
|
|
75
|
+
* verb. Media refs in the markdown must already be valid STORAGE keys (the
|
|
76
|
+
* file-adapter uploads local files + rewrites refs BEFORE calling this).
|
|
77
|
+
*
|
|
78
|
+
* onConflict 'skip' → skip if the path exists
|
|
79
|
+
* onConflict 'update' → replace_document (full-set PUT)
|
|
80
|
+
*/
|
|
81
|
+
importMarkdown(input: {
|
|
82
|
+
markdown: string;
|
|
83
|
+
path?: string;
|
|
84
|
+
onConflict: 'skip' | 'update';
|
|
85
|
+
}): Promise<ImportMarkdownResult>;
|
|
25
86
|
/**
|
|
26
87
|
* Download a file from a URL to a local path.
|
|
27
88
|
*/
|
package/dist/client.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* API client for Moxn KB
|
|
3
3
|
*/
|
|
4
4
|
import * as fs from 'fs/promises';
|
|
5
|
+
import { formatApiError } from './api-error.js';
|
|
5
6
|
export class MoxnClient {
|
|
6
7
|
apiUrl;
|
|
7
8
|
apiKey;
|
|
@@ -166,6 +167,101 @@ export class MoxnClient {
|
|
|
166
167
|
}
|
|
167
168
|
return response.json();
|
|
168
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Get a document's GRAMMAR markdown (front-matter + body + `:::image/:::csv/
|
|
172
|
+
* :::file/:::db` embeds with STORAGE refs) via the `get_document_markdown`
|
|
173
|
+
* export action. This is the grammar interchange the local file-adapter
|
|
174
|
+
* writes to disk (Phase 9a-3) — the export IS the MCP `read`.
|
|
175
|
+
*
|
|
176
|
+
* `forEdit: true` keeps the H2 `{#id}` section anchors so a re-import can
|
|
177
|
+
* address sections stably.
|
|
178
|
+
*/
|
|
179
|
+
async getDocumentMarkdown(documentId, opts) {
|
|
180
|
+
const response = await fetch(`${this.apiUrl}/api/v1/kb/export`, {
|
|
181
|
+
method: 'POST',
|
|
182
|
+
headers: {
|
|
183
|
+
'Content-Type': 'application/json',
|
|
184
|
+
'x-api-key': this.apiKey,
|
|
185
|
+
},
|
|
186
|
+
body: JSON.stringify({
|
|
187
|
+
action: 'get_document_markdown',
|
|
188
|
+
documentId,
|
|
189
|
+
forEdit: opts?.forEdit ?? false,
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
if (!response.ok) {
|
|
193
|
+
const body = await response.text();
|
|
194
|
+
throw new Error(`Failed to get document markdown ${documentId}: ${response.status} ${body}`);
|
|
195
|
+
}
|
|
196
|
+
const data = await response.json();
|
|
197
|
+
return (data.result?.document ?? null);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Build a `storageKey → signedDownloadUrl` map for a document's media, by
|
|
201
|
+
* reusing the `get_document_content` export action (the blocks read, which
|
|
202
|
+
* resolves each storage ref to a fresh signed URL). The grammar markdown
|
|
203
|
+
* carries storage KEYS in its embed `ref`s — this is how the file-adapter
|
|
204
|
+
* turns a key into a downloadable URL WITHOUT a new server endpoint.
|
|
205
|
+
* Returns an empty map when the document has no resolvable media.
|
|
206
|
+
*/
|
|
207
|
+
async getDocumentMediaUrls(documentId) {
|
|
208
|
+
const response = await fetch(`${this.apiUrl}/api/v1/kb/export`, {
|
|
209
|
+
method: 'POST',
|
|
210
|
+
headers: {
|
|
211
|
+
'Content-Type': 'application/json',
|
|
212
|
+
'x-api-key': this.apiKey,
|
|
213
|
+
},
|
|
214
|
+
body: JSON.stringify({ action: 'get_document_content', documentId }),
|
|
215
|
+
});
|
|
216
|
+
if (!response.ok) {
|
|
217
|
+
const body = await response.text();
|
|
218
|
+
throw new Error(`Failed to get document content ${documentId}: ${response.status} ${body}`);
|
|
219
|
+
}
|
|
220
|
+
const data = await response.json();
|
|
221
|
+
const doc = data.result?.document;
|
|
222
|
+
const map = new Map();
|
|
223
|
+
if (!doc)
|
|
224
|
+
return map;
|
|
225
|
+
for (const section of doc.sections) {
|
|
226
|
+
for (const block of section.content) {
|
|
227
|
+
if (block.storageKey && block.url && !block.url.startsWith('data:')) {
|
|
228
|
+
map.set(block.storageKey, block.url);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return map;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* UPSERT a document from GRAMMAR markdown via the `import_markdown` action.
|
|
236
|
+
* The inverse of {@link getDocumentMarkdown}: front-matter → name/path/tags/
|
|
237
|
+
* refs/properties, body → sections, all via the shipped `replace_document`
|
|
238
|
+
* verb. Media refs in the markdown must already be valid STORAGE keys (the
|
|
239
|
+
* file-adapter uploads local files + rewrites refs BEFORE calling this).
|
|
240
|
+
*
|
|
241
|
+
* onConflict 'skip' → skip if the path exists
|
|
242
|
+
* onConflict 'update' → replace_document (full-set PUT)
|
|
243
|
+
*/
|
|
244
|
+
async importMarkdown(input) {
|
|
245
|
+
const response = await fetch(`${this.apiUrl}/api/v1/kb/import`, {
|
|
246
|
+
method: 'POST',
|
|
247
|
+
headers: {
|
|
248
|
+
'Content-Type': 'application/json',
|
|
249
|
+
'x-api-key': this.apiKey,
|
|
250
|
+
},
|
|
251
|
+
body: JSON.stringify({
|
|
252
|
+
action: 'import_markdown',
|
|
253
|
+
markdown: input.markdown,
|
|
254
|
+
path: input.path,
|
|
255
|
+
onConflict: input.onConflict,
|
|
256
|
+
}),
|
|
257
|
+
});
|
|
258
|
+
if (!response.ok) {
|
|
259
|
+
const body = await response.json().catch(() => ({}));
|
|
260
|
+
throw new Error(formatApiError(response.status, body));
|
|
261
|
+
}
|
|
262
|
+
const data = await response.json();
|
|
263
|
+
return data.result;
|
|
264
|
+
}
|
|
169
265
|
/**
|
|
170
266
|
* Download a file from a URL to a local path.
|
|
171
267
|
*/
|
package/dist/export.d.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Export runner for Moxn KB
|
|
2
|
+
* Export runner for Moxn KB (Phase 9a-3 — grammar markdown).
|
|
3
3
|
*
|
|
4
|
-
* Exports documents from Moxn Knowledge Base to local markdown files
|
|
5
|
-
*
|
|
4
|
+
* Exports documents from Moxn Knowledge Base to local GRAMMAR markdown files
|
|
5
|
+
* (front-matter + body + `:::image/:::csv/:::file/:::db` embeds), with
|
|
6
|
+
* downloaded media assets and embed `ref`s rewritten to RELATIVE paths so the
|
|
7
|
+
* exported folder is self-contained and re-imports identically.
|
|
8
|
+
*
|
|
9
|
+
* This is the inverse of the local import (`runMigration` over the LocalSource):
|
|
10
|
+
* - export: `get_document_markdown` → write `.md`; download media; rewrite
|
|
11
|
+
* `:::…{ref="<storage-key>"}` → `ref="<relative-path>"`.
|
|
12
|
+
* - import: read `.md`; upload media; rewrite `ref` back to a storage key;
|
|
13
|
+
* `import_markdown`.
|
|
6
14
|
*/
|
|
7
15
|
import type { ExportOptions, ExportLog } from './types.js';
|
|
8
16
|
export declare function runExport(outputDir: string, options: ExportOptions): Promise<ExportLog>;
|
package/dist/export.js
CHANGED
|
@@ -1,140 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Export runner for Moxn KB
|
|
2
|
+
* Export runner for Moxn KB (Phase 9a-3 — grammar markdown).
|
|
3
3
|
*
|
|
4
|
-
* Exports documents from Moxn Knowledge Base to local markdown files
|
|
5
|
-
*
|
|
4
|
+
* Exports documents from Moxn Knowledge Base to local GRAMMAR markdown files
|
|
5
|
+
* (front-matter + body + `:::image/:::csv/:::file/:::db` embeds), with
|
|
6
|
+
* downloaded media assets and embed `ref`s rewritten to RELATIVE paths so the
|
|
7
|
+
* exported folder is self-contained and re-imports identically.
|
|
8
|
+
*
|
|
9
|
+
* This is the inverse of the local import (`runMigration` over the LocalSource):
|
|
10
|
+
* - export: `get_document_markdown` → write `.md`; download media; rewrite
|
|
11
|
+
* `:::…{ref="<storage-key>"}` → `ref="<relative-path>"`.
|
|
12
|
+
* - import: read `.md`; upload media; rewrite `ref` back to a storage key;
|
|
13
|
+
* `import_markdown`.
|
|
6
14
|
*/
|
|
7
15
|
import * as fs from 'fs';
|
|
8
16
|
import * as path from 'path';
|
|
9
17
|
import { MoxnClient } from './client.js';
|
|
10
18
|
import { matchesDateFilter } from './date-filter.js';
|
|
19
|
+
import { extractMediaRefs, rewriteMediaRefs, } from './grammar-media.js';
|
|
11
20
|
// ──────────────────────────────────────────────
|
|
12
21
|
// Utility functions
|
|
13
22
|
// ──────────────────────────────────────────────
|
|
14
|
-
/**
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
function deriveFilename(storageKey, url, fallbackExt) {
|
|
20
|
-
if (storageKey) {
|
|
21
|
-
const parts = storageKey.split('/');
|
|
22
|
-
return parts[parts.length - 1];
|
|
23
|
-
}
|
|
24
|
-
try {
|
|
25
|
-
const urlPath = new URL(url).pathname;
|
|
26
|
-
const basename = urlPath.split('/').pop();
|
|
27
|
-
if (basename && basename.includes('.')) {
|
|
28
|
-
return basename;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
catch {
|
|
32
|
-
// Not a valid URL
|
|
23
|
+
/** Derive a local filename from a storage key (the key's basename). */
|
|
24
|
+
function filenameFromStorageKey(storageKey, fallbackExt) {
|
|
25
|
+
const basename = storageKey.split('/').pop();
|
|
26
|
+
if (basename && basename.length > 0) {
|
|
27
|
+
return basename.includes('.') ? basename : `${basename}${fallbackExt}`;
|
|
33
28
|
}
|
|
34
29
|
return `export-${Date.now()}${fallbackExt}`;
|
|
35
30
|
}
|
|
36
|
-
/**
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
'image
|
|
40
|
-
|
|
41
|
-
'
|
|
42
|
-
|
|
43
|
-
'
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
31
|
+
/** Map an embed directive to its output media dir + a fallback extension. */
|
|
32
|
+
function mediaTargetForDirective(directive, options) {
|
|
33
|
+
switch (directive) {
|
|
34
|
+
case 'image':
|
|
35
|
+
return { dir: options.imageDir, fallbackExt: '' };
|
|
36
|
+
case 'csv':
|
|
37
|
+
return { dir: options.csvDir, fallbackExt: '.csv' };
|
|
38
|
+
case 'file':
|
|
39
|
+
default:
|
|
40
|
+
// `file` embeds are generic attachments (PDFs and everything else).
|
|
41
|
+
return { dir: options.pdfDir, fallbackExt: '' };
|
|
42
|
+
}
|
|
47
43
|
}
|
|
48
|
-
/** Convert a document path to a local file path. */
|
|
44
|
+
/** Convert a KB document path to a local `.md` file path. */
|
|
49
45
|
function docPathToFilePath(docPath) {
|
|
50
46
|
return `${docPath.replace(/^\//, '')}.md`;
|
|
51
47
|
}
|
|
52
48
|
// ──────────────────────────────────────────────
|
|
53
|
-
// Markdown builder
|
|
54
|
-
// ──────────────────────────────────────────────
|
|
55
|
-
function buildMarkdown(doc, mediaMap, mdFilePath) {
|
|
56
|
-
const lines = [];
|
|
57
|
-
lines.push(`# ${doc.name}`);
|
|
58
|
-
lines.push('');
|
|
59
|
-
if (doc.description) {
|
|
60
|
-
lines.push(doc.description);
|
|
61
|
-
lines.push('');
|
|
62
|
-
}
|
|
63
|
-
for (const section of doc.sections) {
|
|
64
|
-
lines.push(`## ${section.name}`);
|
|
65
|
-
lines.push('');
|
|
66
|
-
for (const block of section.content) {
|
|
67
|
-
if (block.blockType === 'text' && block.text) {
|
|
68
|
-
lines.push(stripCommentTags(block.text));
|
|
69
|
-
lines.push('');
|
|
70
|
-
}
|
|
71
|
-
else if (block.blockType === 'image' && block.url) {
|
|
72
|
-
const key = block.storageKey || block.url;
|
|
73
|
-
const localPath = mediaMap.get(key);
|
|
74
|
-
if (localPath) {
|
|
75
|
-
const relativePath = path.relative(path.dirname(mdFilePath), localPath);
|
|
76
|
-
lines.push(``);
|
|
77
|
-
}
|
|
78
|
-
else {
|
|
79
|
-
lines.push(``);
|
|
80
|
-
}
|
|
81
|
-
lines.push('');
|
|
82
|
-
}
|
|
83
|
-
else if (block.blockType === 'document' && block.url) {
|
|
84
|
-
const key = block.storageKey || block.url;
|
|
85
|
-
const localPath = mediaMap.get(key);
|
|
86
|
-
if (localPath) {
|
|
87
|
-
const relativePath = path.relative(path.dirname(mdFilePath), localPath);
|
|
88
|
-
lines.push(`[${block.filename || 'document'}](${relativePath})`);
|
|
89
|
-
}
|
|
90
|
-
else {
|
|
91
|
-
lines.push(`[${block.filename || 'document'}](${block.url})`);
|
|
92
|
-
}
|
|
93
|
-
lines.push('');
|
|
94
|
-
}
|
|
95
|
-
else if (block.blockType === 'csv' && block.url) {
|
|
96
|
-
const key = block.storageKey || block.url;
|
|
97
|
-
const localPath = mediaMap.get(key);
|
|
98
|
-
if (localPath) {
|
|
99
|
-
const relativePath = path.relative(path.dirname(mdFilePath), localPath);
|
|
100
|
-
lines.push(`[${block.filename || 'data.csv'}](${relativePath})`);
|
|
101
|
-
}
|
|
102
|
-
else {
|
|
103
|
-
lines.push(`[${block.filename || 'data.csv'}](${block.url})`);
|
|
104
|
-
}
|
|
105
|
-
lines.push('');
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
// Trim trailing blank lines, end with single newline
|
|
110
|
-
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
|
111
|
-
lines.pop();
|
|
112
|
-
}
|
|
113
|
-
lines.push('');
|
|
114
|
-
return lines.join('\n');
|
|
115
|
-
}
|
|
116
|
-
// ──────────────────────────────────────────────
|
|
117
|
-
// Media helpers
|
|
118
|
-
// ──────────────────────────────────────────────
|
|
119
|
-
function getMediaTarget(block, options) {
|
|
120
|
-
if (block.blockType === 'image') {
|
|
121
|
-
return {
|
|
122
|
-
targetDir: options.imageDir,
|
|
123
|
-
fallbackExt: mimeToExt(block.mimeType || 'image/png'),
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
else if (block.blockType === 'document') {
|
|
127
|
-
return {
|
|
128
|
-
targetDir: options.pdfDir,
|
|
129
|
-
fallbackExt: mimeToExt(block.mimeType || 'application/pdf'),
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
else if (block.blockType === 'csv') {
|
|
133
|
-
return { targetDir: options.csvDir, fallbackExt: '.csv' };
|
|
134
|
-
}
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
// ──────────────────────────────────────────────
|
|
138
49
|
// Main export runner
|
|
139
50
|
// ──────────────────────────────────────────────
|
|
140
51
|
export async function runExport(outputDir, options) {
|
|
@@ -192,64 +103,90 @@ export async function runExport(outputDir, options) {
|
|
|
192
103
|
const mediaFiles = [];
|
|
193
104
|
console.error(`Processing: ${docItem.path}`);
|
|
194
105
|
try {
|
|
195
|
-
|
|
196
|
-
|
|
106
|
+
// 1. Grammar markdown body (front-matter + body + embeds with storage refs).
|
|
107
|
+
// CLEAN export (no `forEdit`): the H2 `{#id}` section anchors are
|
|
108
|
+
// DROPPED. Those ids belong to THIS source document; the local import
|
|
109
|
+
// feeds the grammar straight to `replace_document`, which on a re-home
|
|
110
|
+
// (import to a NEW path / new doc) REJECTS foreign ids
|
|
111
|
+
// (`section id … not found on branch main`). `replace_document`'s
|
|
112
|
+
// diff-derived identity already preserves ids on a same-path UPDATE by
|
|
113
|
+
// content-matching, so the anchors buy nothing there and only break the
|
|
114
|
+
// CREATE/re-home case. Matches the `/api/v1/kb/export` default contract
|
|
115
|
+
// and the kb-import-markdown round-trip test (both use the clean export).
|
|
116
|
+
const doc = await client.getDocumentMarkdown(docItem.id);
|
|
117
|
+
if (!doc) {
|
|
118
|
+
throw new Error('document is not a grammar-markdown document (no body)');
|
|
119
|
+
}
|
|
120
|
+
const mdRelativePath = docPathToFilePath(docItem.path);
|
|
197
121
|
const mdFullPath = path.join(outputDir, mdRelativePath);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
122
|
+
// 2. Discover the media storage keys referenced in the body.
|
|
123
|
+
const mediaRefs = extractMediaRefs(doc.markdown);
|
|
124
|
+
// 3. Resolve storage keys → signed download URLs (reuses the blocks read).
|
|
125
|
+
// Only fetch when there's media to download.
|
|
126
|
+
const signedUrls = mediaRefs.length
|
|
127
|
+
? await client.getDocumentMediaUrls(docItem.id)
|
|
128
|
+
: new Map();
|
|
129
|
+
// 4. Download each unique media key + build a key → relative-path map.
|
|
130
|
+
const keyToRelPath = new Map();
|
|
131
|
+
for (const { directive, ref } of mediaRefs) {
|
|
132
|
+
// Skip non-storage refs (e.g. an external URL someone hand-authored)
|
|
133
|
+
// and refs we've already handled.
|
|
134
|
+
if (keyToRelPath.has(ref))
|
|
135
|
+
continue;
|
|
136
|
+
const signedUrl = signedUrls.get(ref);
|
|
137
|
+
if (!signedUrl) {
|
|
138
|
+
// No signed URL for this ref — leave it as-is (rewrite skips null).
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const target = mediaTargetForDirective(directive, options);
|
|
142
|
+
const filename = filenameFromStorageKey(ref, target.fallbackExt);
|
|
143
|
+
const localRelativePath = path.join(target.dir, filename);
|
|
144
|
+
const localFullPath = path.join(outputDir, localRelativePath);
|
|
145
|
+
if (!options.dryRun) {
|
|
146
|
+
if (!fs.existsSync(localFullPath)) {
|
|
147
|
+
try {
|
|
148
|
+
await client.downloadFile(signedUrl, localFullPath);
|
|
149
|
+
totalMediaDownloaded++;
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
153
|
+
console.error(` ✗ Download failed: ${filename}: ${msg}`);
|
|
154
|
+
// Leave the embed ref pointing at the storage key (no rewrite).
|
|
155
|
+
continue;
|
|
226
156
|
}
|
|
227
157
|
}
|
|
228
|
-
else {
|
|
229
|
-
totalMediaDownloaded++;
|
|
230
|
-
}
|
|
231
|
-
mediaFiles.push(localRelativePath);
|
|
232
158
|
}
|
|
159
|
+
else {
|
|
160
|
+
totalMediaDownloaded++;
|
|
161
|
+
}
|
|
162
|
+
// The embed ref is rewritten to a path RELATIVE to the .md file.
|
|
163
|
+
const refRelativeToMd = path.relative(path.dirname(mdFullPath), localFullPath);
|
|
164
|
+
keyToRelPath.set(ref, refRelativeToMd);
|
|
165
|
+
mediaFiles.push(localRelativePath);
|
|
233
166
|
}
|
|
234
|
-
|
|
167
|
+
// 5. Rewrite the embed refs (storage key → relative path). Refs with no
|
|
168
|
+
// download (keyToRelPath miss) are left untouched.
|
|
169
|
+
const markdown = rewriteMediaRefs(doc.markdown, (ref) => keyToRelPath.get(ref) ?? null);
|
|
235
170
|
if (!options.dryRun) {
|
|
236
171
|
fs.mkdirSync(path.dirname(mdFullPath), { recursive: true });
|
|
237
172
|
fs.writeFileSync(mdFullPath, markdown, 'utf-8');
|
|
238
173
|
}
|
|
239
|
-
console.error(`
|
|
174
|
+
console.error(` ✓ ${mdRelativePath} (${mediaFiles.length} media)`);
|
|
240
175
|
results.push({
|
|
241
|
-
documentId:
|
|
242
|
-
documentPath:
|
|
176
|
+
documentId: docItem.id,
|
|
177
|
+
documentPath: docItem.path,
|
|
243
178
|
outputFile: mdRelativePath,
|
|
244
179
|
status: 'exported',
|
|
245
|
-
|
|
180
|
+
// Grammar export is body-grain, not section-grain; sections are no
|
|
181
|
+
// longer enumerated client-side.
|
|
182
|
+
sectionsCount: 0,
|
|
246
183
|
mediaFiles,
|
|
247
184
|
duration: Date.now() - docStart,
|
|
248
185
|
});
|
|
249
186
|
}
|
|
250
187
|
catch (error) {
|
|
251
188
|
const message = error instanceof Error ? error.message : String(error);
|
|
252
|
-
console.error(`
|
|
189
|
+
console.error(` ✗ Failed: ${message}`);
|
|
253
190
|
results.push({
|
|
254
191
|
documentId: docItem.id,
|
|
255
192
|
documentPath: docItem.path,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|